Skip to content

✨ server: add wallet provisioning endpoint#949

Open
aguxez wants to merge 1 commit intomainfrom
server-provisioning
Open

✨ server: add wallet provisioning endpoint#949
aguxez wants to merge 1 commit intomainfrom
server-provisioning

Conversation

@aguxez
Copy link
Copy Markdown
Contributor

@aguxez aguxez commented Apr 10, 2026


Open with Devin

Summary by CodeRabbit

  • New Features

    • Added a wallet provisioning endpoint so authenticated users can retrieve wallet and card provisioning details.
  • Tests

    • Added comprehensive tests for the wallet endpoint covering success, processor errors, missing card/panda, and missing credential scenarios.
  • Chores

    • Added a changeset declaring a patch release with a release note about the new wallet provisioning endpoint.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 10, 2026

🦋 Changeset detected

Latest commit: 0975d4d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@exactly/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@aguxez aguxez force-pushed the server-provisioning branch from 7de9bc2 to 1d0aa0f Compare April 10, 2026 09:27
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Walkthrough

Adds a new authenticated GET /wallet endpoint that looks up a user's credential and active/frozen card, calls Panda API for processor-level card details, and returns a processor card ID and time-based secret; includes tests and a changeset declaring a patch release.

Changes

Cohort / File(s) Summary
Wallet Endpoint
server/api/card.ts
New authenticated GET /wallet route: reads credentialId from cookie, selects credential (pandaId + first ACTIVE
Panda Utility
server/utils/panda.ts
Added exported getProcessorDetails(cardId: string) which GETs /issuing/cards/${cardId}/processorDetails and validates/returns { processorCardId, timeBasedSecret }.
Tests
server/test/api/card.test.ts
Added tests for wallet endpoint covering success (active/frozen cards), mocking panda.getProcessorDetails, and error mappings: Panda 404→404, Panda 5xx→500, no card→404, no panda→403, no credential→500.
Release Metadata
.changeset/chilly-suns-dress.md
New changeset declaring a patch release for @exactly/server describing the wallet provisioning endpoint.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as GET /wallet
    participant DB as Database
    participant Panda as Panda API

    Client->>API: GET /wallet (credentialId cookie)
    API->>DB: Load credential (pandaId, first ACTIVE|FROZEN card)
    alt no credential
        DB-->>API: not found
        API-->>Client: 500 { code: "no credential" }
    else credential missing pandaId
        DB-->>API: credential (no pandaId)
        API-->>Client: 403 { code: "no panda" }
    else no eligible card
        DB-->>API: credential (no card)
        API-->>Client: 404 { code: "no card" }
    else credential + card found
        DB-->>API: credential + cardId
        API->>Panda: GET /issuing/cards/{cardId}/processorDetails
        alt processor details found
            Panda-->>API: { processorCardId, timeBasedSecret }
            API-->>Client: 200 { cardId, cardSecret }
        else processor 404
            Panda-->>API: 404
            API-->>Client: 404 { code: "no card" }
        else other error
            Panda-->>API: 5xx
            API-->>Client: 500 (error)
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • cruzdanilo
  • nfmelendez
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'server: add wallet provisioning endpoint' accurately summarizes the main change, which introduces a new GET /wallet endpoint for wallet provisioning.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch server-provisioning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@aguxez aguxez marked this pull request as draft April 10, 2026 09:27
devin-ai-integration[bot]

This comment was marked as resolved.

gemini-code-assist[bot]

This comment was marked as resolved.

@sentry
Copy link
Copy Markdown

sentry bot commented Apr 10, 2026

✅ All tests passed.

@aguxez aguxez force-pushed the server-provisioning branch 3 times, most recently from af7c987 to 9495bf1 Compare April 10, 2026 13:10
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 88412791-ba8b-402b-8896-65830bb065b6

📥 Commits

Reviewing files that changed from the base of the PR and between e6efb1b and 9495bf1.

📒 Files selected for processing (4)
  • .changeset/chilly-suns-dress.md
  • server/api/card.ts
  • server/test/api/card.test.ts
  • server/utils/panda.ts

Comment on lines +582 to +625
.get(
"/wallet",
auth(),
describeRoute({
summary: "Get wallet provisioning credentials",
tags: ["Card"],
security: [{ credentialAuth: [] }],
validateResponse: true,
responses: {
200: {
description: "Wallet provisioning credentials",
content: {
"application/json": {
schema: resolver(WalletResponse, { errorMode: "ignore" }),
},
},
},
403: {
description: "Forbidden",
content: {
"application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
},
},
},
}),
async (c) => {
const { credentialId } = c.req.valid("cookie");
const credential = await database.query.credentials.findFirst({
where: eq(credentials.id, credentialId),
columns: { pandaId: true },
with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } },
});
if (!credential) return c.json({ code: "no credential" }, 500);
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
if (!credential.cards[0]) return c.json({ code: "no card" }, 404);
try {
const provisioning = await getProcessorDetails(credential.cards[0].id);
return c.json({ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput<typeof WalletResponse>, 200);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Protect cardSecret the same way card PAN/CVC are protected.

timeBasedSecret is wallet-provisioning material, but this route returns it in clear text behind only the signed auth cookie. The existing GET / flow requires a caller-provided sessionid and encrypts the returned card secrets; /wallet drops that extra protection for an equally sensitive credential. Please reuse the same session-bound envelope here, or another equivalent proof-of-possession mechanism, before returning cardSecret.

Comment on lines +615 to +625
const credential = await database.query.credentials.findFirst({
where: eq(credentials.id, credentialId),
columns: { pandaId: true },
with: { cards: { columns: { id: true }, where: inArray(cards.status, ["ACTIVE", "FROZEN"]) } },
});
if (!credential) return c.json({ code: "no credential" }, 500);
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
if (!credential.cards[0]) return c.json({ code: "no card" }, 404);
try {
const provisioning = await getProcessorDetails(credential.cards[0].id);
return c.json({ cardId: provisioning.processorCardId, cardSecret: provisioning.timeBasedSecret } satisfies InferOutput<typeof WalletResponse>, 200);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Choose the wallet card deterministically.

The relation query can return multiple ACTIVE/FROZEN rows, and credential.cards[0] then depends on database order. The schema shown for cards does not enforce a single non-deleted card per credential, so this endpoint can provision the wrong card if stale eligible rows coexist. Add an explicit ordering or otherwise constrain the query to the intended current card before calling Panda.

@aguxez aguxez force-pushed the server-provisioning branch from 9495bf1 to 0975d4d Compare April 10, 2026 13:46
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
server/api/card.ts (2)

613-626: ⚠️ Potential issue | 🔴 Critical

Protect wallet provisioning secrets with proof-of-possession.

timeBasedSecret is returned in clear text behind only the auth cookie, while the existing GET / flow requires a caller-provided sessionid and returns encrypted card secrets. /wallet is exposing equivalent provisioning material with weaker protection.


615-625: ⚠️ Potential issue | 🟠 Major

Choose the wallet card deterministically.

This query can return multiple ACTIVE/FROZEN rows, and credential.cards[0] then depends on database order. server/database/schema.ts:24-54 only indexes credential_id, so stale eligible rows can make /wallet provision the wrong card.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 45ecefb7-8b50-4ff9-b2a0-080f60291d69

📥 Commits

Reviewing files that changed from the base of the PR and between 9495bf1 and 0975d4d.

📒 Files selected for processing (4)
  • .changeset/chilly-suns-dress.md
  • server/api/card.ts
  • server/test/api/card.test.ts
  • server/utils/panda.ts

Comment on lines +590 to +611
responses: {
200: {
description: "Wallet provisioning credentials",
content: {
"application/json": {
schema: resolver(WalletResponse, { errorMode: "ignore" }),
},
},
},
403: {
description: "Forbidden",
content: {
"application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
},
},
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the 500 responses this route already emits.

validateResponse is enabled, but the handler returns 500 { code: "no credential" } on Line 620 and can propagate other 5xx failures on Lines 627-629. The OpenAPI block omits 500 entirely, so the contract does not match runtime behavior.

🩹 Proposed contract update
       responses: {
           description: "Wallet provisioning credentials",
           content: {
             "application/json": {
               schema: resolver(WalletResponse, { errorMode: "ignore" }),
             },
           },
         },
           description: "Forbidden",
           content: {
             "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
           },
         },
           description: "Not found",
           content: {
             "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) },
           },
         },
+        500: {
+          description: "Internal server error",
+          content: {
+            "application/json": { schema: resolver(object({ code: literal("no credential") }), { errorMode: "ignore" }) },
+          },
+        },
       },

Also applies to: 620-629

@aguxez aguxez marked this pull request as ready for review April 10, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant